﻿////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// <copyright>Copyright 2008-2011 Andreas Huber Doenni</copyright>
// Distributed under the GNU General Public License version 2 (GPLv2).
// See accompanying file License.txt or copy at http://phuse.codeplex.com/license.
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

namespace Phuse.Net.Forums
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics.CodeAnalysis;
    using System.Globalization;
    using System.IO;
    using System.Linq;
    using System.Net;
    using System.Reflection;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Phone.Data.Linq;

    /// <summary>Represents a local cache of information sent to or received from one or more forum servers.</summary>
    /// <threadsafety static="true" instance="false"/>
    [SuppressMessage("Microsoft.Naming", "CA1724:TypeNamesShouldNotMatchNamespaces", Justification = "Applications also using the System.Net.Cache namespace can disambiguate by using Forums.Cache.")]
    public sealed class Cache : IDisposable
    {
        // This is to keep memory consumption at acceptable levels when performing an operation on many articles.
        internal const int MaxBatchSize = 1024;

        private readonly FileIOProvider fileIOProvider;
        private readonly int maxInboundArticleOverflow;
        private Database database;
        private Task sendReceiveTask;

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        /// <summary>Initializes a new instance of the <see cref="Cache"/> class by calling
        /// <see cref="Cache(string, ICryptoProvider, int)"/>(<paramref name="dataDirectory"/>,
        /// <paramref name="cryptoProvider"/>, 500).</summary>
        public Cache(string dataDirectory, ICryptoProvider cryptoProvider) : this(dataDirectory, cryptoProvider, 500)
        {
        }

        /// <summary>Initializes a new instance of the <see cref="Cache"/> class.</summary>
        /// <param name="dataDirectory">The directory where the cache data is located.</param>
        /// <param name="cryptoProvider">The provider that is used to encrypt and decrypt the values of
        /// <see cref="IHost.AccountName"/> and <see cref="IHost.Password"/> in the cache.</param>
        /// <param name="maxInboundArticleOverflow">The total maximum number of excess articles that all overflowing
        /// groups can contain before <see cref="ShouldCompact"/> returns <c>true</c>.</param>
        /// <exception cref="ArgumentException"><paramref name="dataDirectory"/> contains at least one invalid
        /// character.</exception>
        /// <exception cref="ArgumentNullException"><paramref name="dataDirectory"/> and/or
        /// <paramref name="cryptoProvider"/> equal <c>null</c>.</exception>
        /// <exception cref="ArgumentOutOfRangeException"><paramref name="maxInboundArticleOverflow"/> is negative.
        /// </exception>
        /// <exception cref="FileNotFoundException"><paramref name="dataDirectory"/> does not exist or does not contain
        /// cache data.</exception>
        /// <exception cref="IncompatibleCacheException"><paramref name="dataDirectory"/> contains incompatible data.
        /// </exception>
        /// <remarks>
        /// <para>For each distinct value of <paramref name="dataDirectory"/> on a given OS instance, at most one object
        /// of the <see cref="Cache"/> class may exist in any process at any given time. Failure to follow this rule may
        /// result in unspecified behavior.</para>
        /// <para>To create a new empty cache, call <see cref="CreateCache"/>.</para>
        /// </remarks>
        public Cache(string dataDirectory, ICryptoProvider cryptoProvider, int maxInboundArticleOverflow)
        {
            if (maxInboundArticleOverflow < 0)
            {
                throw new ArgumentOutOfRangeException("maxInboundArticleOverflow", "Must not be negative.");
            }

            this.maxInboundArticleOverflow = maxInboundArticleOverflow;

            using (var fileIOGuard = ScopeGuard.Create(this.fileIOProvider = new FileIOProvider(dataDirectory)))
            using (var dataGuard =
                ScopeGuard.Create(this.database = new Database(this, fileIOGuard.Resource, cryptoProvider)))
            {
                int version;

                try
                {
                    version = this.database.CreateDatabaseSchemaUpdater().DatabaseSchemaVersion;
                }
                catch (InvalidOperationException ex)
                {
                    throw new IncompatibleCacheException("Unexpected number of rows in the __VERSION table.", ex);
                }
                catch (InvalidCastException ex)
                {
                    throw Root.GetIncompatibleException(ex);
                }
                catch (Exception ex)
                {
                    Root.HandleSqlCeException(ex);
                    throw;
                }

                if (version != 0)
                {
                    throw new IncompatibleCacheException("Database version mismatch.");
                }

                dataGuard.Dismiss();
                fileIOGuard.Dismiss();
            }
        }

        /// <summary>Resets the internal state such that this object behaves exactly like a newly constructed
        /// <see cref="Cache"/> object.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        /// <remarks>
        /// <para>Renders all previously retrieved direct and indirect child objects (e.g. <see cref="IHost"/>,
        /// <see cref="IGroup"/>, etc.) invalid. After calling this method, client code that needs to perform addtional
        /// operations must do so on newly retrieved objects (e.g. through <see cref="Hosts"/>,
        /// <see cref="IHost.Groups"/>, etc.).</para>
        /// <para>The main use case for this method is when client code wants to operate on an up-to-date version of the
        /// cache content after <see cref="SendReceiveAsync"/> has completed.</para>
        /// </remarks>
        public void Reset()
        {
            this.database.AssertNotDisposedCache();
            var old = this.database;
            this.database = this.database.Clone();
            old.Dispose();
        }

        /// <summary>Gets the directory passed to the constructor.</summary>
        public string DataDirectory
        {
            get { return this.fileIOProvider.RootDirectory; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IHost.Name"/> property can have.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int HostNameMaxLength
        {
            get { return this.database.Root.HostNameMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IHost.UserName"/> property can have.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int UserNameMaxLength
        {
            get { return this.database.Root.UserNameMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IHost.EmailAddress"/> property can have.
        /// </summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int EmailAddressMaxLength
        {
            get { return this.database.Root.EmailAddressMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IHost.AccountName"/> property can have.
        /// </summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int AccountNameMaxLength
        {
            get { return this.database.Root.AccountNameMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IHost.Password"/> property can have.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int PasswordMaxLength
        {
            get { return this.database.Root.PasswordMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IGroup.Name"/> property can have.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int GroupNameMaxLength
        {
            get { return this.database.Root.GroupNameMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IGroup.DisplayName"/> property can have.
        /// </summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int GroupDisplayNameMaxLength
        {
            get { return this.database.Root.GroupDisplayNameMaxLength; }
        }

        /// <summary>Gets the maximum length the value of the <see cref="IOutboundArticle.Subject"/> property can have.
        /// </summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public int ArticleSubjectMaxLength
        {
            get { return this.database.Root.OutboundArticleSubjectMaxLength; }
        }

        /// <summary>Returns the return value of
        /// <see cref="AddHost{T, U}(string, int, string, string, string, string)"/>(
        /// <paramref name="name"/>, <paramref name="port"/>, null, null, null, null).</summary>
        /// <typeparam name="TProvider">The forum provider to use for send/receive.</typeparam>
        /// <typeparam name="TConnection">The type that represents a connection with the host.</typeparam>
        [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "There's no other way.")]
        public IHost AddHost<TProvider, TConnection>(string name, int port)
            where TProvider : IForumProvider<TConnection>, new()
            where TConnection : Connection
        {
            return this.AddHost<TProvider, TConnection>(name, port, null, null, null, null);
        }

        /// <summary>Adds a new host to the cache and returns an object representing the host.</summary>
        /// <typeparam name="TProvider">The forum provider to use for send/receive.</typeparam>
        /// <typeparam name="TConnection">The type that represents a connection with the host.</typeparam>
        /// <exception cref="ArgumentOutOfRangeException"><paramref name="port"/> is not between
        /// <see cref="IPEndPoint.MinPort"/> and <see cref="IPEndPoint.MaxPort"/>.</exception>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "There's no other way.")]
        public IHost AddHost<TProvider, TConnection>(
            string name, int port, string userName, string emailAddress, string accountName, string password)
            where TProvider : IForumProvider<TConnection>, new()
            where TConnection : Connection
        {
            return this.database.AddHost<TProvider, TConnection>(name, port, userName, emailAddress, accountName, password);
        }

        /// <summary>Gets the hosts that have been added with <see cref="AddHost(string, int)"/>.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public IEnumerable<IHost> Hosts
        {
            get { return this.database.Hosts; }
        }

        /// <summary>Gets the outbound articles that have the specified send status and where
        /// <see cref="IOutboundArticle.IsDeleted"/> equals <c>false</c>.</summary>
        /// <param name="sendStatus">The <see cref="IOutboundArticle.SendStatus"/> the returned objects must have.
        /// </param>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public IEnumerable<IOutboundArticle> GetOutboundArticles(SendStatus sendStatus)
        {
            return this.database.GetOutboundArticles(sendStatus);
        }

        /// <summary>Gets the outbound articles where <see cref="IOutboundArticle.IsDeleted"/> equals <c>true</c>.
        /// </summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This is an expensive operation.")]
        public IEnumerable<IOutboundArticle> GetDeletedOutboundArticles()
        {
            return this.database.GetDeletedOutboundArticles();
        }

        /// <summary>Gets the number of articles where <see cref="IOutboundArticle.IsDeleted"/> equals <c>false</c> for
        /// each possible value of <see cref="SendStatus"/>.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This is an expensive operation.")]
        public IDictionary<SendStatus, int> GetOutboundArticleCounts()
        {
            return this.database.GetOutboundArticleCounts();
        }

        /// <summary>Starts an asynchronous send/receive operation.</summary>
        /// <param name="groups">Articles from these groups will be received.</param>
        /// <param name="receiveContents">Pass <c>true</c> to mark the newest articles such that the article content
        /// will also be received in this send/receive operation.</param>
        /// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
        /// <param name="progress">Reports progress on the various operations executed during send/receive. Pass
        /// <c>null</c> to not monitor progress.</param>
        /// <exception cref="ArgumentNullException"><paramref name="groups"/> equals <c>null</c>.</exception>
        /// <exception cref="InvalidOperationException">Another send/receive operation is already in progress.
        /// </exception>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        /// <remarks>
        /// <para>The caller is responsible for calling <see cref="Task.Dispose()"/> on the returned <see cref="Task"/>
        /// object.</para>
        /// <para>The caller must also ensure that the returned task completes before <see cref="Cache.Dispose()"/> is
        /// called (if necessary, the caller may cancel the send/receive operation through a passed
        /// <see cref="CancellationToken"/> beforehand). Failure to follow this rule may result in unspecified behavior.
        /// </para>
        /// <para>Any call to this method will likely lead to changes to inbound and outbound articles in the underlying
        /// database. However, due to in-memory caching, such changes will often not be visible through the
        /// <see cref="IInboundArticle"/> and <see cref="IOutboundArticle"/> objects, not even if they are retrieved
        /// after <see cref="Task.IsCompleted"/> of the returned <see cref="Task"/> is <c>true</c>. To make the changes
        /// visible, <see cref="Reset"/> should be called after the Send/Receive operation has completed.</para>
        /// <para>The receive portion of a send/receive operation has two phases:
        /// <list type="number">
        /// <item>The headers of the newest articles is received for all specified groups. In any event,
        /// information of at most
        /// <see cref="IGroup.NewInboundArticlesReceiveLimit">IGroup.NewInboundArticlesReceiveLimit</see> articles is
        /// received for each group. If articles from a given group have been received before, only the information
        /// about the articles posted since the last send/receive operation are retrieved. If
        /// <paramref name="receiveContents"/> is <c>true</c> then each article is also marked for content receive in
        /// phase 2.</item>
        /// <item>The content of all articles that have been marked for content receive is retrieved. This includes the
        /// articles that have been marked for content receive in phase 1 as well as the articles that have been marked
        /// by setting <see cref="IInboundArticle.IsBeingWatched"/> to true.</item>
        /// </list></para>
        /// </remarks>
        public Task SendReceiveAsync(
            IEnumerable<IGroup> groups,
            bool receiveContents,
            CancellationToken cancellationToken,
            IProgress<ISendReceiveProgress> progress)
        {
            this.database.AssertNotDisposedCache();
            this.AssertSendReceiveNotInProgress();

            if (groups == null)
            {
                throw new ArgumentNullException("groups");
            }

            var dataDirectory = this.DataDirectory;
            var cryptoProvider = this.database.CryptoProvider;
            var groupIds = (from current in groups select ((Group)current).Id).ToList();
            Action action = () => DoSendReceive(
                dataDirectory, cryptoProvider, groupIds, receiveContents, cancellationToken, progress);
            this.sendReceiveTask = new Task(action, cancellationToken, TaskCreationOptions.LongRunning);
            this.sendReceiveTask.Start();
            return this.sendReceiveTask;
        }

        /// <summary>Gets a value indicating whether the cache should be compacted.</summary>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public bool ShouldCompact
        {
            get
            {
                return GetOverflowingGroups(this.database).Aggregate(0, (count, pair) => count + pair.Value) >
                    this.maxInboundArticleOverflow;
            }
        }

        /// <summary>Compacts the cache.</summary>
        /// <exception cref="InvalidOperationException">A send/receive operation is in progress. All child objects
        /// remain valid.</exception>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        /// <remarks><strong>Note</strong>: Calls <see cref="Reset"/> before returning.</remarks>
        public void Compact()
        {
            this.AssertSendReceiveNotInProgress();
            this.Reset(); // This avoids the submission of currently pending changes.
            DeleteOldInboundArticles(this.database, from pair in GetOverflowingGroups(this.database) select pair.Key);
            DeleteFiles(this.database.Clone);
            DeleteTempFilesAndEmptyDirectories(this.fileIOProvider, string.Empty);
            this.Reset();
        }

        /// <summary>Saves the changes made through methods like <see cref="AddHost(string, int)"/>,
        /// <see cref="IHost.AddGroup"/>, <see cref="IDeletablePersistentObject.Delete"/>,
        /// <see cref="IOutboundArticle.SaveMessage"/>, etc. and properties like <see cref="IHost.UserName"/>,
        /// <see cref="IOutboundArticle.SendStatus"/>, etc..</summary>
        /// <returns><c>false</c> if all child objects remain valid. <c>true</c> if one or more of the committed changes
        /// required a call to <see cref="Reset"/>.</returns>
        /// <exception cref="ArgumentException"><list type="bullet">
        /// <item>The length of a string property of an entity (e.g. <see cref="IHost"/>, <see cref="IGroup"/>, etc.)
        /// exceeds the maximum allowed length (the maximum allowed length for these properties can be retrieved through
        /// properties like <see cref="Cache.HostNameMaxLength"/>), and/or</item>
        /// <item>the values of both <see cref="IHost.Name"/> and <see cref="IHost.Port"/> of a newly added host are
        /// equal to the values of an already existing host, and/or</item>
        /// <item>the values of both <see cref="IGroup.Host"/> and <see cref="IGroup.Name"/> of a newly added group are
        /// equal to the values of an already existing group.</item>
        /// </list></exception>
        /// <exception cref="ArgumentNullException">At least one of the properties <see cref="IHost.Name"/>,
        /// <see cref="IGroup.Name"/> or <see cref="IGroup.DisplayName"/> of a newly added or modified entity equals
        /// <c>null</c>.</exception>
        /// <exception cref="InvalidOperationException">
        /// A send/receive operation is in progress and at least one of the following changes was
        /// made:<list type="bullet">
        /// <item><see cref="IDeletablePersistentObject.Delete"/> has been called on at least one
        /// <see cref="IHost"/> or <see cref="IGroup"/> object</item>
        /// <item><see cref="IOutboundArticle.IsDeleted"/> or <see cref="IOutboundArticle.SendStatus"/> was modified</item>
        /// </list></exception>
        /// <exception cref="ObjectDisposedException"><see cref="Dispose"/> has been called.</exception>
        public bool SaveChanges()
        {
            this.database.SubmitChanges();
            var reopen = this.database.ReopenAfterCommit;

            if (reopen)
            {
                this.Reset();
            }

            return reopen;
        }

        /// <summary>See <see cref="IDisposable.Dispose"/>.</summary>
        /// <remarks>Calling this method immediately renders all objects invalid that have been directly or indirectly
        /// retrieved through this object (e.g. <see cref="IHost"/> and <see cref="IGroup"/> objects).</remarks>
        [SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", MessageId = "sendReceiveTask", Justification = "The client is responsible for disposal.")]
        public void Dispose()
        {
            this.database.Dispose();
            this.fileIOProvider.Dispose();
        }

        /// <summary>Creates an empty cache in <paramref name="dataDirectory"/>.</summary>
        /// <param name="dataDirectory">The directory where the data of the cache will be located. Must either be empty
        /// or must not yet exist.</param>
        /// <exception cref="ArgumentException"><paramref name="dataDirectory"/> is either not a legal directory path or
        /// is a non-empty directory.</exception>
        /// <exception cref="ArgumentNullException"><paramref name="dataDirectory"/> equals <c>null</c>.</exception>
        /// <exception cref="DirectoryNotFoundException">The specified path is invalid (for example, it is on an
        /// unmapped drive).</exception>
        /// <exception cref="IOException"><list type="bullet">
        /// <item>The directory specified by <paramref name="dataDirectory"/> is read-only, or</item>
        /// <item>The directory specified by <paramref name="dataDirectory"/> exceeds the maximum length. For example,
        /// on Windows-based platforms, paths must be less than 248 characters. Note that the cache will itself create a
        /// directory structure under <paramref name="dataDirectory"/>, so the length of
        /// <paramref name="dataDirectory"/> must be considerably lower than the maximum.</item>
        /// </list></exception>
        /// <exception cref="NotSupportedException"><paramref name="dataDirectory"/> contains a colon character (:) that
        /// is not part of a drive label ("C:\").</exception>
        /// <exception cref="UnauthorizedAccessException">The caller does not have the required permission.</exception>
        /// <remarks>If they do not yet exist, creates all directories and subdirectories as specified by
        /// <paramref name="dataDirectory"/>. Then creates a new empty cache in <paramref name="dataDirectory"/>, such
        /// that a <see cref="Cache"/> object can be created by passing <paramref name="dataDirectory"/>.</remarks>
        public static void CreateCache(string dataDirectory)
        {
            using (var fileIOProvider = new FileIOProvider(dataDirectory))
            {
                Database.CreateDatabase(fileIOProvider);
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        internal void AssertSendReceiveNotInProgress()
        {
            if (this.sendReceiveTask != null)
            {
                if (this.sendReceiveTask.IsCompleted)
                {
                    this.sendReceiveTask = null;
                }
                else
                {
                    throw new InvalidOperationException("A send/receive operation is already in progress.");
                }
            }
        }

        internal static void ReopenDatabase(
            Func<Database> openDatabase, int groupId, ref Database database, out Group group, out Root root)
        {
            if (database != null)
            {
                database.Dispose();
            }

            using (var guard = ScopeGuard.Create(openDatabase()))
            {
                database = guard.Resource;
                group = GetGroup(database, groupId);
                root = group.Root;
                guard.Dismiss();
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Dispose() is called, CA bug?")]
        private static void DoSendReceive(
            string dataDirectory,
            ICryptoProvider cryptoProvider,
            IList<int> groupIds,
            bool receiveContents,
            CancellationToken cancellationToken,
            IProgress<ISendReceiveProgress> progress)
        {
            if (progress == null)
            {
                progress = new NullProgressObserver();
            }

            using (var fileIOProvider = new FileIOProvider(dataDirectory))
            using (var database = new Database(null, fileIOProvider, cryptoProvider))
            {
                Connection connection = null;

                try
                {
                    foreach (var group in GetGroups(database, groupIds))
                    {
                        var helper = GetHelper(database, group, receiveContents, progress);
                        connection = helper.SendReceive(connection, cancellationToken);
                    }
                }
                finally
                {
                    if (connection != null)
                    {
                        connection.Dispose();
                    }
                }
            }
        }

        private static void DeleteOldInboundArticles(Database database, IEnumerable<Group> overflowingGroups)
        {
            foreach (var group in overflowingGroups)
            {
                var oldArticleCount =
                    database.Root.GetInboundArticles(group).Count() - group.CachedInboundArticlesLimit;
                var maxDeletedArticleNumber = DeleteOldInboundArticles(database.Clone, group.Id, oldArticleCount);
                ScheduleOldInboundArticleFilesForDeletion(database.Clone, group.Id, maxDeletedArticleNumber);
            }
        }

        private static void DeleteFiles(Func<Database> openDatabase)
        {
            Database database = openDatabase();

            try
            {
                List<DeleteFile> deleteBatch;

                while ((deleteBatch = database.DeleteFileTable.Take(MaxBatchSize).ToList()).Count > 0)
                {
                    var fileIOProvider = database.FileIOProvider;

                    foreach (var deleteFile in deleteBatch)
                    {
                        fileIOProvider.Delete(deleteFile.Path);
                        database.DeleteOnSubmit(deleteFile);
                    }

                    database.SubmitChanges();

                    // The following statements unnecessarily reopen the database even if there is no second batch.
                    // However, compared to other operations executed during Compact(), this inefficiency will likely
                    // not be noticeable.
                    database.Dispose();
                    database = openDatabase();
                }
            }
            finally
            {
                database.Dispose();
            }
        }

        private static long DeleteOldInboundArticles(Func<Database> openDatabase, int groupId, int oldArticleCount)
        {
            Database database = null;

            try
            {
                InboundArticle lastDeletedArticle = null;

                while (oldArticleCount > 0)
                {
                    Group group;
                    Root root;
                    ReopenDatabase(openDatabase, groupId, ref database, out group, out root);

                    var deleteBatch =
                        group.Root.GetInboundArticles(group).Take(Math.Min(oldArticleCount, MaxBatchSize / 2)).ToList();

                    foreach (var article in deleteBatch)
                    {
                        lastDeletedArticle = article;
                        root.DeleteOnSubmit(article);
                    }

                    var makeOriginalBatch =
                        group.Root.GetInboundArticlesReferencingOldOnes(group, lastDeletedArticle.Id).ToList();

                    foreach (var article in makeOriginalBatch)
                    {
                        article.MakeOriginal();
                    }

                    root.InsertsAndDeletesSinceLastCompaction += deleteBatch.Count;
                    oldArticleCount -= deleteBatch.Count;
                    database.SubmitChanges();
                }

                return lastDeletedArticle == null ? 0 : lastDeletedArticle.Number;
            }
            finally
            {
                if (database != null)
                {
                    database.Dispose();
                }
            }
        }

        private static void ScheduleOldInboundArticleFilesForDeletion(
            Func<Database> openDatabase, int groupId, long maxDeletedArticleNumber)
        {
            Database database = null;
            Group group;
            Root root;
            ReopenDatabase(openDatabase, groupId, ref database, out group, out root);

            try
            {
                var minDataFileToKeep = InboundArticle.GetDataFilePath(
                    group.DataDirectory, group.CachedInboundArticlesLimit, maxDeletedArticleNumber + 1);
                var minDataFileNumberToKeep =
                    long.Parse(Path.GetFileNameWithoutExtension(minDataFileToKeep), CultureInfo.InvariantCulture);

                foreach (var file in database.Root.FileIOProvider.GetFiles(group.DataDirectory, "*.dat").ToList())
                {
                    long dataFileNumber;

                    if (long.TryParse(Path.GetFileNameWithoutExtension(file), out dataFileNumber) &&
                        (dataFileNumber < minDataFileNumberToKeep))
                    {
                        // Theoretically this should be done in batches as well, but given the current numbers for
                        // MaxBatchSize and InboundArticle.FilesPerGroup, it seems rather unlikely that we ever hit
                        // MaxBatchSize.
                        database.Root.InsertOnSubmit(new DeleteFile() { Path = file });
                    }
                }

                database.SubmitChanges();
            }
            finally
            {
                database.Dispose();
            }
        }

        private static IList<Group> GetGroups(Database database, IList<int> groupIds)
        {
            var groups = new List<Group>(groupIds.Count);

            foreach (var groupId in groupIds)
            {
                try
                {
                    groups.Add(GetGroup(database, groupId));
                }
                catch (InvalidOperationException ex)
                {
                    throw new ArgumentException("The group with the Id " + groupId + " does not exist.", ex);
                }
            }

            return groups;
        }

        private static Group GetGroup(Database database, int groupId)
        {
            return database.GroupTable.Where(group => group.Id == groupId).Single();
        }

        private static IEnumerable<KeyValuePair<Group, int>> GetOverflowingGroups(Database database)
        {
            return
                from article in database.InboundArticleTable
                group article by article.Group into g
                let overflow = g.Count() - g.Key.CachedInboundArticlesLimit
                where (overflow > 0) &&
                    ((from article in database.InboundArticleTable
                      where (article.Group == g.Key) && !article.HasBeenRead
                      select article).FirstOrDefault() == null)
                select new KeyValuePair<Group, int>(g.Key, overflow);
        }

        private static void DeleteTempFilesAndEmptyDirectories(FileIOProvider provider, string directory)
        {
            foreach (var subDirectory in provider.GetDirectories(directory))
            {
                DeleteTempFilesAndEmptyDirectories(provider, subDirectory);
            }

            foreach (var tempFile in provider.GetFiles(directory, "*.tmp"))
            {
                provider.DeleteFile(tempFile);
            }

            if (provider.IsEmptyDirectory(directory))
            {
                provider.DeleteDirectory(directory);
            }
        }

        private static ISendReceiveHelper GetHelper(
            Database database, Group group, bool receiveContents, IProgress<ISendReceiveProgress> progress)
        {
            var providerType = Type.GetType(group.Host.Provider);
            var providerInterfaceType = providerType.GetInterfaces().Where(t => t.IsGenericType &&
                (t.GetGenericTypeDefinition() == typeof(IForumProvider<>))).Single();
            var provider = providerType.GetConstructor(Type.EmptyTypes).Invoke(null);

            var helperType = typeof(SendReceiveHelper<>).MakeGenericType(
                providerInterfaceType.GetGenericArguments().Single());
            var helperParameterTypes =
                new[]
                {
                    providerInterfaceType,
                    typeof(SendReceiveContext),
                    typeof(bool),
                    typeof(IProgress<ISendReceiveProgress>)
                };
            var helperConstructor = helperType.GetConstructor(
                BindingFlags.Instance | BindingFlags.NonPublic, null, helperParameterTypes, null);

            var helperArguments =
                new[] { provider, new SendReceiveContext(database.Clone, group.Id), receiveContents, progress };
            return (ISendReceiveHelper)helperConstructor.Invoke(helperArguments);
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        /// <summary>Represents a progress observer that does nothing.</summary>
        private sealed class NullProgressObserver : IProgress<ISendReceiveProgress>
        {
            /// <summary>See <see cref="IProgress{T}.Report"/>.</summary>
            public void Report(ISendReceiveProgress value)
            {
            }
        }
    }
}
